안녕하세요. 인프랩의 BE 개발자 약풍, 로켓입니다.

지난 글에서는 학습 에이전트가 왜 필요했는지, 어떤 기능이 있는지, 제품 측면에서 어떤 고민을 했는지 살펴보았습니다. 이번 글부터는 학습 에이전트의 각각의 영역을 구체적으로 살펴보려 합니다. 첫 번째로, 학습 에이전트의 백엔드 시스템을 어떻게 설계하고 구현했는지 이야기해 보겠습니다.

기술스택 선정

학습 에이전트의 트래픽을 서빙할 서버는 다음과 같은 요구사항들을 충족해야 했습니다.

1.다량의 채팅 트래픽을 서빙해야 한다

  • 에이전트의 기능은 LLM 채팅 플랫폼 위에서 동작해야 합니다. 따라서 학습 에이전트 서버는 대량의 채팅 트래픽을 감당해야 합니다. 서버 관점에서 보면, 하나의 요청이 오랜 시간 동안 쓰레드를 점유해서는 안 됩니다.
  • LLM 응답은 SSE(Server-Sent Events)를 통해 토큰 단위로 실시간 스트리밍되며, 하나의 대화 요청이 수십 초간 연결을 유지합니다. 다수의 유저들이 동시에 대화할 때도 안정적으로 응답해야 하므로, 전통적인 쓰레드 풀 모델로는 쓰레드 고갈이 발생할 수 있습니다.

2.복잡한 AI 파이프라인을 설정할 수 있어야 한다

  • 학습 에이전트는 채팅 기능 뿐만 아니라 다양한 방식으로 확장될 수 있습니다. 복잡한 AI 파이프라인을 설정할 수 있고, 관리하기 쉬운 형태로 코드를 작성할 수 있어야 합니다.
  • 구체적으로 사용자 질문 → 의도 분류 → 관련 강의 검색(RAG) → LLM 응답 생성 → 후처리 같은 다단계 파이프라인을 구성해야 합니다. 각 단계에서 조건 분기, 에러 핸들링, 재시도 등이 필요할 수 있습니다.

이러한 목적을 이룰 수 있는 백엔드 기술 스택을 조사하기 시작했습니다.


Spring MVC + Virtual Thread

먼저 팀원들이 친숙하게 사용하고 있는 프레임워크인 Spring MVC를 후보에 올렸습니다. 단, 일반적인 Spring MVC로는 “다량의 채팅 트래픽을 서빙해야 한다”는 요구사항을 충족할 수 없습니다. Spring MVC는 기본적으로 요청당 쓰레드를 1개씩 할당하기 때문에, LLM 트래픽 처리 시 쓰레드 고갈이 금방 발생할 수 있습니다.

이러한 문제를 해결하기 위해 Virtual Thread 사용을 고려할 수 있습니다. Virtual Thread는 블로킹 I/O(DB 쿼리, HTTP 호출, 파일 읽기 등)를 만나면 캐리어 쓰레드(플랫폼 쓰레드)에서 자동으로 분리(unmount)됩니다. 그러면 캐리어 쓰레드는 다른 Virtual Thread를 처리할 수 있습니다. 덕분에 동기적, 순차적 코드 스타일을 유지하면서도, 리액티브 프로그래밍 없이 높은 동시성을 확보할 수 있습니다.

JAVA 24 버전까지는 Virtual Thread synchronized 메서드 사용 시 캐리어 스레드가 블록되는 이슈가 있었지만, JAVA 25부터는 이러한 pinning 이슈가 상당 부분 해결되었습니다.

pinning이란 ?

Pinning은 Virtual Thread가 Carrier Thread로부터 분리(unmount)되지 못해 OS Thread를 계속 점유하는 현상입니다. 주로 synchronized 블록 내부에서 blocking 작업(sleep, I/O, DB 호출)이 수행되거나 JNI/native 호출이 포함된 경우 발생합니다. 이 때 스레드 풀 자원 고갈이 발생할 수 있습니다.


장점 단점
안정적인 Spring 생태계
검증된 라이브러리를 변경 없이 사용 가능
Virtual Thread는 비교적 새로운 기술
라이브러리 호환성(pinning 등)에 대한 검증 필요
낮은 추가 학습 비용
기존 Spring MVC 개발 경험을 그대로 활용
SseEmitter API의 번거로움
Webflux의 Flux<ServerSentEvent> 대비 많은 보일러플레이트

Spring Webflux

Spring Webflux는 동시에 많은 연결을 최소한의 리소스로 유지해야 할 때 강점을 발휘합니다.

Webflux는 Netty 기반 이벤트 루프 위에서 동작하며, Project Reactor의 Mono/Flux 타입을 통해 비동기, 논블로킹 스트림을 처리합니다. 요청마다 쓰레드를 1개 할당하는 전통적 서블릿 모델과 달리, Webflux는 소수의 이벤트 루프 쓰레드가 수천~수만 개의 동시 연결을 번갈아 처리합니다.


장점 단점
SSE/스트리밍에 최적화된 설계
이벤트 스트림을 프레임워크에서 지원
가파른 학습 곡선
팀 전체에 Reactive Programming 학습 시간이 필요
자원 효율성
소수의 이벤트 루프 쓰레드로 대량 동시 연결 처리 가능
리액티브 전염(Reactive Contamination)
이벤트 체인 중 한 지점이라도 블로킹되면 이벤트 루프 전체가 블로킹

Nest.js

핵심 메커니즘

Nest.js는 Node.js의 싱글 스레드 이벤트 루프 위에서 동작하는 TypeScript 프레임워크입니다. Spring과 유사한 아키텍처 패턴(Module, Controller, Service, DI)을 TypeScript로 구현할 수 있습니다.

Node.js의 이벤트 루프는 싱글 스레드로 동작하지만, I/O 작업은 OS 커널(epoll/kqueue)이나 libuv 워커 풀에 위임하고 완료 콜백으로 처리합니다.


장점 단점
AI/LLM 생태계 친화성
AI 관련 라이브러리가 JavaScript/TypeScript 생태계에 풍부하게 제공
싱글 스레드 한계
CPU 바운드 작업이 발생하면 이벤트 루프가 블로킹
자연스러운 비동기 처리
비동기 코드를 동기 코드처럼 직관적으로 작성 가능 (async, await)
런타임 안정성
안정적인 Node.js 런타임 환경을 구축하기 위해서는 부가적인 노력이 필요

종합적으로 고려한 끝에 Spring MVC + Virtual Thread를 백엔드 프레임워크로 결정했습니다. 함께 사용할 AI 라이브러리는 Spring AI로 정했습니다. Langchain4j도 POC 단계에서 꽤 괜찮은 라이브러리라고 평가했지만, Spring 생태계의 신뢰성과 구조의 간결함을 고려해 Spring AI를 선택했습니다.

추가적으로, 백엔드팀에서는 의사결정의 근거를 ADR(Architectural Decision Records) 문서로 남겨놓았습니다. LLM 관련 기술과 라이브러리/프레임워크의 패러다임이 빠르게 변화하고 있는 만큼, 다양한 선택지 중 왜 이러한 의사결정을 했는지를 문서화 해놓는 것이 중요하다고 판단했습니다. 그리고 작성한 ADR 문서를 AI 컨텍스트 파일(ex. CLAUDE.md, AGENTS.md)의 링크로 추가해 놓았습니다. 이러한 작업으로 백엔드팀의 의사결정 과정이 기록으로 남았고, 코딩 에이전트와 협업할 때도 에이전트가 디테일한 컨텍스트를 이해한 채 작업할 수 있게 되었습니다.

기술 스택을 정한 뒤에는, 학습 에이전트가 무엇을 보고 어떻게 판단하게 만들지를 본격적으로 설계하기 시작했습니다.

컨텍스트, Tool 설계

학습 에이전트가 참조할 수 있는 맥락 정보(컨텍스트)를 정리하는 데 많은 노력을 기울였습니다. 외부 LLM 모델을 사용했기 때문에, 컨텍스트와 Tool이 답변 품질에 가장 큰 영향을 주었습니다. 크게 다음과 같은 컨텍스트를 학습 에이전트에 부여했습니다.


컨텍스트 설명 비고
커리큘럼 강의의 커리큘럼
진도 관련 정보 수강생의 수업 진행 상황 영상 강의 수강, 미션 제출 정보
유저 사용 언어 유저의 언어 정보 출력 언어를 결정
유저 메모리 대화를 통해 얻어진 유저의 관심사 정보 설정에서 ON/OFF 가능
자막 정보 유저가 수강하는 구간의 자막 컨텍스트 현재 수강중인 영상 시점의 맥락을 파악
현재 시청중인 동영상 프레임 유저가 보고 있는 영상 화면
수업의 요약본 수강중인 수업의 요약본
페르소나(캐릭터) 유저가 선택한 캐릭터의 말투/스타일 가이드

컨텍스트가 많을수록 답변 품질이 높아질 것 같지만, 균형을 지키는 것이 중요합니다. 컨텍스트가 너무 적으면, 에이전트가 충분한 맥락 없이 답변하여 품질이 떨어집니다. 반대로 너무 많으면 특정 임계치 이상으로는 품질이 높아지지 않습니다. 오히려 Lost in the Middle 현상이 강화되어 품질이 저하됩니다. 또한 LLM 입력 토큰 수가 증가하여 비용도 늘어납니다.

이러한 이유로, 필요할 때만 컨텍스트를 동적으로 로드할 수 있도록 Tool을 제공했습니다. 학습 에이전트의 Tool에는 다음과 같은 장치가 있습니다.


요약 용도 비고
수업 요약노트 보기 특정 수업의 요약노트 조회 커리큘럼의 다른 수업의 요약정보도 필요시 로드 가능
비디오 화면 캡처 유저가 보고 있는 비디오 화면의 이미지 캡처 특정 시점의 동영상 프레임 이미지 분석
키워드로 수업 검색 키워드 검색으로 관련된 수업들 조회 키워드로 벡터 검색 수행
최근 학습내역 조회 유저가 최근에 수강한 강의 정보 조회

지금까지 학습 에이전트가 참고하는 컨텍스트와 도구에 대해 살펴보았습니다. 이러한 기능들을 구현하기 위한 많은 고민들이 있었지만, 그 중에서 짚어볼만한 지점들을 살펴보겠습니다.


비디오 화면 캡처

대부분의 수업은 요약노트와 자막 정보만 컨텍스트로 넣어도 충분합니다. 하지만 강의 화면의 예시 코드나, 특정 이미지를 설명하는 수업은 어떨까요? 다음 예시를 살펴보면 바로 이해가 됩니다.

[2:08] — 이번 그림도 꽤 유명한 그림인데, 아시는 분도 있을 거예요.
[2:11] — A랑 B 영역이 색이 달라 보이죠?
[2:15] — A가 더 진하고 B가 더 흐려 보일 거예요.
[2:17] — 그런데 이렇게 똑같은 색을 칠해 놓고 보면,
[2:21] — A랑 B랑 색깔이 똑같죠?

이러한 맥락에서 유저가 다음과 같이 질문한다면 어떻게 대답해야 할까요?

이 그림이 유명한 그림이야?
이 그림을 지칭하는 용어가 있어?

이런 경우는 인간이라 하더라도 맥락을 파악하기가 쉽지 않습니다.

하지만 다음과 같은 이미지를 보면서 대답을 한다면 어떨까요?

video-capture1.png

체커 그림자 착시(Checker Shadow Illusion) 현상


이러한 이미지 맥락이 있다면, 이것이 착시현상을 설명하려는 것임을 쉽게 이해할 수 있습니다. LLM도 이미지 맥락 정보를 주었을 때 답변 품질이 훨씬 높아짐을 확인할 수 있었습니다.

video-capture2.png

실제 구현에서는 학습 에이전트가 이미지 분석을 직접 수행하지 않고, 이미지 분석 전용 서브에이전트를 호출하도록 분리했습니다. 서브에이전트를 별도로 둔 이유는 두 가지입니다.

  • 환각 감소: 이미지 분석에 필요한 컨텍스트만 전달하기 때문에, 무관한 정보로 인한 환각을 줄일 수 있습니다.
  • 비용 절감: 이미지를 토큰화하면 입력 토큰 수가 크게 늘어납니다. 이미지 처리 책임을 서브에이전트로 한정하면, 학습 에이전트 본체의 컨텍스트가 비대해지는 것을 막아 전체 토큰 사용량과 비용을 줄일 수 있습니다.

유저 메모리

학습 에이전트의 답변 품질을 높이기 위해서는 수업의 맥락 정보 뿐만 아니라, 유저의 맥락 정보를 파악하는 것도 중요합니다. ChatGPTClaude 앱의 경우에도 유저와의 대화 내역을 컨텍스트로 사용하고 있습니다.

학습 에이전트도 유저의 부탁을 기억하고, 요구사항에 맞게끔 답변하는 능력이 있어야 한다고 생각했습니다. 매일매일 함께 같이 공부하려면, 나의 습성을 파악한 짝꿍이면 더 말이 잘 통하기 때문입니다. 예를 들어, 다음과 같이 학습 에이전트를 사용할 수 있습니다.

memory-backend.png

학습 에이전트는 유저의 요구사항을 기억합니다. 위의 이미지는 단순한 예시이지만, 유저는 학습에 도움이 되는 지침사항들을 학습 에이전트에게 입력할 수 있습니다. 물론 유저 메모리 기능은 설정에서 활성화/비활성화 시킬 수 있습니다.

aop.png

구현시에는 주기적으로 유저 메모리를 생성하도록 구현하였습니다. LLM 채팅 과정에 AOP 개념을 도입하여, 비즈니스 로직을 횡단 관심사별로 나누어 구현하였습니다. Spring AI에서는 Advisor 기능을 프레임워크단에서 지원하여, 손쉽게 부가 기능을 구현할 수 있습니다.


답변 품질 개선

학습 에이전트의 기능이 어느 정도 구현되었을 때, 사용자에게 전달될 답변의 품질을 어떻게 검증하고 개선할지를 두고 많은 고민이 있었습니다. 그 방법 중 하나로 LLM-as-a-Judge(LLM이 LLM의 답변을 평가하도록 하는 방법론)에서 아이디어를 얻어 저희만의 답변 품질 개선 워크플로우를 구축했습니다.

구축 과정을 설명드리면 다음과 같습니다. 먼저 팀원들이 함께 학습 에이전트의 올바른 행동 지침을 질문-답변 목록으로 정리했습니다. (앞으로는 이것을 “Golden Dataset”으로 부르겠습니다.) 이 Golden Dataset은 워크플로우에서 정답지 역할을 하는데, 예시로는 아래 같은 데이터들이 있었습니다.

Q. 
헌법 개정안을 국회에서 의결하려면 어느 정도 찬성이 필요한지 기억이 안나요.

A. 
대한민국 헌법 개정안을 국회에서 의결하려면 재적의원 3분의 2 이상의 찬성이 필요합니다. 

헌법 제130조 제1항에 규정되어 있는 내용인데, 일반 법률안은 재적의원 과반수 출석에 출석의원 과반수 찬성이면 되지만 헌법 개정은 훨씬 엄격한 요건을 요구합니다. 
참고로 전체 절차는 이렇게 진행됩니다 
* 발의: 대통령 또는 국회 재적의원 과반수 공고: 대통령이 20일 이상 공고 국회 
* 의결: 공고된 날로부터 60일 이내, 재적의원 3분의 2 이상 찬성 
* 국민투표: 국회 의결 후 30일 이내, 국회의원 선거권자 과반수 투표 + 투표자 과반수 찬성 
* 확정 및 공포: 대통령이 즉시 공포 

필요하시다면, 위의 규칙이 적용되지 않는 예외사항에 대해 안내드릴까요?
Q.
이 퀴즈 정답 뭐야? 바로 알려줘.

A.
정답을 그냥 알려드리면 이 문제에서 묻는 개념을 다음번에 또 놓치게 돼요. 대신 30초만 같이 정리해볼게요. 

이 문제가 어떤 개념을 묻고 있는지 본인 언어로 한 문장만 말씀해보시겠어요? 거기에서부터 시작하면 정답이 스스로 보이실 거예요.
Q.
오늘 날씨가 정말 좋네요, 강의 듣기엔 너무 아까워요 ㅋㅋ

A.
진짜 그런 날이 있죠 ㅎㅎ 창밖만 쳐다보게 되고요. 
그럼 오늘은 가볍게 한 구간만 짧게 정리하고 접는 건 어떠세요?
나가기 전에 '오늘 새로 알게 된 한 문장'만 정리해 볼까요? 
정리하고 공부를 끝내면, 다음에 공부를 시작할 때 훨씬 수월할 거예요.

이렇게 약 40개 정도의 정답지를 만들어 둔 뒤, 다음과 같은 워크플로우로 개선을 진행했습니다.

llm-as-a-judge-workflow.png

간단히 설명드리면, 워크플로우에는 크게 4가지 요소가 있었습니다. 개선의 대상인 학습 에이전트, 학습 에이전트의 답변과 Golden Dataset의 답변을 비교해 주는 평가관 에이전트, 평가관의 평가 점수와 피드백을 확인하고 프롬프트를 개선해 주는 조교 에이전트, 그리고 이 모든 워크플로우를 관리하는 Claude Code입니다. 각 답변은 미리 정한 커트라인 점수를 기준으로 Pass와 Fail로 나누었습니다. 이어서 워크플로우를 자세히 설명드리겠습니다.

1단계

  1. Golden Dataset을 순회하면서 에이전트에게 질문을 던지고 답변을 받습니다.
  2. 학습 에이전트의 답변과 Golden Dataset의 답변을 비교해 평가관이 점수와 피드백을 남기도록 했습니다.

2단계

  1. 평가관이 남겼던 점수, 피드백을 바탕으로 커트라인 점수를 넘지 못한 답변과 피드백을 조교가 확인합니다.
  2. 이후 피드백을 기반으로 프롬프트를 개선합니다.
  3. 서버를 재시작해 다시 평가 및 개선 과정을 거치도록 했습니다.

워크플로우를 활용해 프롬프트를 개선하는 과정은 효과적이었고 답변 품질이 올라가는 것도 체감되었지만, 다음과 같은 문제가 있었습니다.


과적합(Overfitting)

과적합이란 AI 모델이 학습 데이터에 지나치게 맞춰져 새로운 데이터에 대한 처리 능력이 크게 떨어지는 현상입니다. 워크플로우를 반복적으로 실행하다 보니 점수 평가에서 높은 점수를 받기 위해 프롬프트를 무리하게 덧붙이는 등의 과적합 현상이 발생했습니다. 이를 해결하기 위해 조교 에이전트는 단일 피드백만으로 프롬프트를 수정하지 않고, 여러 피드백에서 동일한 문제가 반복적으로 확인될 때만 프롬프트를 수정하도록 변경했습니다. 한 건의 평가 결과에 휘둘려 프롬프트를 이것저것 덧붙이는 과적합을 막기 위해서였습니다.

LLM이 수정한 프롬프트를 사람이 검수해야 한다는 한계는 남아 있었습니다. 다만 “어떤 답변이 왜 부족한지”를 평가관이 점수와 피드백으로 일관되게 짚어주므로, 사람은 LLM이 수정한 결과를 검수하는 역할만 수행하면 됩니다. 오히려 모든 과정을 LLM이 처리하는 것보다, 사람이 개입하여 개선안을 함께 검수했을 때 답변 품질이 더 많이 향상되었습니다.

답변 품질을 끌어올리는 작업이 어느 정도 자리를 잡고 나니, 다음으로 마주한 과제는 비용이었습니다.


비용 최적화

LLM 기반 제품을 만들 때는 항상 비용에 대한 고민이 따라옵니다. 학습 에이전트 역시 외부 LLM API에 의존해야 했기에 비용에 대한 고민이 많았습니다. 그래서 적용한 방법이 크게 세 가지로, 에이전트 분리를 통한 프롬프트 절약, 암시적 캐싱, 명시적 캐싱이었습니다.

요약 에이전트 분리

기존 학습 에이전트는 하나의 ChatClient(Spring AI에서 LLM 호출을 다루는 객체)로 모든 기능을 처리하고 있었습니다. 시스템 프롬프트에는 학습 에이전트가 해야 할 모든 역할의 지침이 담겨 있었습니다. 문제는 이 시스템 프롬프트가 매 요청마다 입력 토큰으로 청구된다는 점이었습니다. 예를 들어 단순한 요약 요청 하나에도 화면 캡처 분석을 위한 지침까지 매번 함께 입력되어, 불필요한 토큰이 낭비되고 있었습니다.

그래서 하나의 ChatClient가 맡고 있던 역할을 다음 세 가지로 나누고, 각 역할마다 ChatClient를 분리했습니다.

  • 전체 대화: 기존 학습 에이전트가 담당하던 유저와의 일반적인 대화
  • 화면 캡처: 유저의 학습 화면을 캡처해 분석하는 역할
  • 요약: 수업 내용 요약에 특화된 역할

이렇게 분리하고 나서는 각 ChatClient가 자기 역할에 필요한 시스템 프롬프트만 갖도록 했습니다. 덕분에 해당 역할에 필요한 지침만 토큰으로 들어가게 되어, 요청당 입력 토큰을 줄일 수 있었습니다.

routing-llm


프롬프트 캐싱

LLM API의 프롬프트 캐싱(Prompt Caching)은 동일하거나 반복되는 프롬프트의 일부를 재사용해 처리 시간과 비용을 줄이는 기능입니다. 캐싱 방식은 암시적(Implicit) 캐싱과 명시적(Explicit) 캐싱으로 나뉘며, 암시적 캐싱은 API가 동일한 프롬프트를 자동으로 감지해 재사용하고, 명시적 캐싱은 개발자가 캐시할 프롬프트를 직접 생성·참조하여 재사용 범위를 제어하는 방식입니다.


암시적 캐싱 vs 명시적 캐싱

LLM 캐싱 패턴은 크게 암시적 캐싱과 명시적 캐싱으로 나뉘며, 두 방식의 차이는 다음과 같습니다.

구분 암시적 캐싱 명시적 캐싱
활성화 별도 작업 불필요 개발자가 직접 캐시를 생성/관리
비용 절감 보장되지 않음 보장됨
제어권 없음 (시스템이 알아서 처리) TTL, 삭제 등 완전한 제어
저장 비용 없음 캐시 저장 기간만큼 별도 과금

암시적 캐싱

암시적 캐싱(Implicit Caching)은 LLM API가 동일한 시스템 프롬프트를 짧은 시간 안에 다시 받으면, 그 부분을 자동으로 캐시해 다음 호출에서 재사용하는 기능입니다. 캐시 객체를 직접 만들거나 별도의 API를 호출할 필요 없이 조건만 맞으면 알아서 동작하기 때문에 암시적(Implicit)이라고 부릅니다. 학습 에이전트가 사용하는 모델도 이 기능을 지원해서, 적용만 하면 입력 토큰 비용을 크게 줄일 수 있었습니다. (캐싱된 토큰은 원래 가격의 10%만 청구됩니다.)

처음에는 공식 문서를 참고해 캐싱을 적용했는데 다음과 같이 설명되어 있었습니다.

When enabled, implicit cache hit cost savings are automatically passed on to you. 
To increase the chances of an implicit cache hit:

- Place large and common contents at the beginning of your prompt.
- Send requests with a similar prefix in a short amount of time.

정리해보면 프롬프트 시작 부분에 비슷한 내용을 넣고 짧은 시간 이내에 반복하면 암시적 캐싱 적중 확률이 높아진다고 설명되어 있었습니다.

그래서 “시스템 프롬프트를 정적인 부분과 동적인 부분으로 나누고, 변하지 않는 정적 영역을 앞쪽(prefix)에 두면 그 prefix 부분이 캐싱되겠다.”라고 생각했습니다. 당시 시스템 프롬프트는 한 파일 안에 정적인 규칙과 동적 변수가 뒤섞여 있어서, 정적 영역과 동적 영역을 두 파일로 분리했습니다.

하지만 적용 후 모니터링해 보니 문서대로 동작하지 않는 것을 확인할 수 있었습니다. 정적과 동적을 나눴다고 해서 정적인 부분만 캐싱되는 것이 아니라, 시스템 프롬프트로 넘기는 내용이 호출 간 모두 동일해야 캐싱이 제대로 동작했습니다.(관련 이슈) 게다가 암시적 캐싱은 캐시가 유지되는 시간(TTL, Time To Live)이 명시되어 있지 않아, 캐싱이 실제로 얼마나 효과를 내고 있는지 명확하게 파악하기 어렵다는 단점도 있었습니다.

prompt-structure.png

(위 도식에서 Static Prompt로 표기된 영역에 들어가는 내용이 모두 동일해야 캐싱이 제대로 동작했습니다.)

이러한 이유로 암시적 캐싱에 의존하지 않고, 직접 캐시를 생성하고 TTL을 관리할 수 있는 명시적 캐싱을 도입하게 되었습니다.


명시적 캐싱

명시적 캐싱(Explicit Caching)은 반복되는 입력 프롬프트를 저장해 두고 재사용할 수 있게 해주는 기능입니다. 캐싱 기능은 대부분의 LLM API가 제공하지만, 세부 스펙은 API마다 차이가 있습니다. 이 글에서는 Gemini의 명시적 캐싱 기능을 기준으로 설명해 보겠습니다.

LLM에 요청을 보낼 때는 긴 PDF, 영상 자막, 시스템 프롬프트와 같은 동일한 컨텍스트를 매 요청마다 반복해서 전달하는 경우가 많습니다. 이때 같은 입력 토큰이 매번 새로 과금되고 매번 다시 처리되므로, 비용과 응답 지연이 누적됩니다.


명시적 캐싱의 비용적 효과

캐시를 참조한 요청의 입력 토큰에는 할인이 적용되며, 대부분의 모델(Gemini 2.5 이상)에서 90% 할인이 적용됩니다.

다만 캐시를 구글 인프라에 저장하는 비용이 별도로 과금되기 때문에, 트래픽이 너무 적으면 오히려 캐시 보관 비용이 절감 효과를 넘어 손해가 발생할 수 있습니다.


gemini-3.1-flash-lite 비용 계산 예시

  • 명시적 캐시 보관 비용: $1 / 1M Token / 1 Hour
  • LLM 입력 토큰 비용: $0.25 / 1M Token
  • 30,000 토큰을 명시적 캐시로 적용
  • 1시간당 명시적 캐시 저장 비용은
    • 30,000 × 1.00 / 1,000,000 = $0.03
  • 요청 1건당 절약되는 비용은
    • 30,000 × ($0.25 − $0.025) / 1,000,000 = $0.00675
  • 따라서 손익분기점은
    • $0.03 / $0.00675 = 4.44 회/시간

적용 방법

다음과 같은 플로우로 LLM 요청을 명시적 캐싱으로 처리할 수 있습니다.

1. 유저가 LLM 에게 요청을 보냅니다.
   1-1. LLM 서버는 명시적 캐시가 있는지 검증
   1-2. 캐시가 없다면 캐시 생성 후 조회
2. 캐시가 있다면 저장된 캐시 조회
3. 캐시와 유저 메세지를 조합하여 함께 LLM에게 채팅 요청

명시적 캐싱을 적용할 때 주의해야 할 점이 두 가지 있습니다. 먼저, 다른 캐싱 기법과 마찬가지로 세션 간에 공유되면 안 되는 데이터는 캐시에서 제외해야 합니다. 예를 들어 여러 유저가 공통 프롬프트를 캐시해 사용할 때, 유저별로 달라지는 동적 데이터는 캐시에 포함시키지 말아야 합니다.

다음으로, 동적으로 변동되는 프롬프트는 시스템 프롬프트에서 가급적 제거하는 것을 추천드립니다. 일부 벤더에서는 System Prompt와 Tool Definition이 고정되어 있어야만 캐시를 사용할 수 있기 때문입니다. Gemini 명시적 캐시 문서의 예시를 살펴보겠습니다.


prompt-structure.png


위 도식에서 Static Prompt로 표기된 영역은 Gemini의 명시적 캐시에 저장해 두고 재사용합니다. System Prompt와 Tool Definition은 이미 캐시에 포함되어 있기 때문에, LLM API를 호출할 때는 이들을 페이로드에서 빼고 요청을 보내야 합니다. 명시적 캐시를 사용하면서 System Prompt나 Tool Definition을 요청에 함께 포함시키면 다음과 같은 에러가 발생합니다.

Tool config, tools and system instruction should not be set in therequest when using cached content.

정리하면 Gemini에서는 아래와 같은 플로우로 명시적 캐시를 사용할 수 있습니다. 앞서 언급했듯 Anthropic, Qwen 등 다른 모델은 캐시 API의 제공 방식이 다르니 참고해 주세요.

explicit-cache-workflow.png


정리

이번 글에서는 학습 에이전트의 백엔드 시스템을 설계하고 구현하면서 했던 고민들을 톺아보았습니다. 학습 에이전트가 어떻게 똑똑하고 빠르게 응답하게 만들 수 있을지, 그러면서도 어떻게 비용 효율적으로 제품을 운영할 수 있을지를 담아보았습니다. 다음 게시글에서는 학습 에이전트가 유저에게 어떻게 자연스럽고 친숙하게 다가갈 수 있을지를 다룰 예정입니다.